﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.SharePoint.Administration;
using Microsoft.SharePoint.Administration.Health;
using SDS.SPHealthAnalyzerRules.Delegation.Helpers;

namespace SDS.SPHealthAnalyzerRules.Delegation.Rules
{
    /// <summary>
    /// Validates that SPN's exist for webapps with Kerb authentication, for every combination of:
    /// - hostname/FQDN/AAM
    /// - port (if not 80/443)
    /// - service account name
    /// </summary>
    public class WebAppHostHeaders_Have_SPNs : SPHealthAnalysisRule
    {
        private Guid CorrelationToken = Guid.NewGuid();

        private struct Error
        {
            public string WebAppName;
            public string Zone;
            public string Url;
            public string ErrorMessage;
            public string Remedy;
        }
        private List<Error> Errors = new List<Error>();
        public struct TestLog
        {
            public string WebAppName { get; set; }
            public string ServiceAccount { get; set; }
            //public string Zones { get; set; }
            public string Url { get; set; }

            public string SPN_Proto { get; set; }
            public string SPN_Address { get; set; }
            public string SPN_Account { get; set; }
        }
        readonly public List<TestLog> CheckLogs = new List<TestLog>();

        public override SPHealthCategory Category { get { return SPHealthCategory.Configuration; } }
        public override SPHealthCheckErrorLevel ErrorLevel { get { return SPHealthCheckErrorLevel.Error; } }

        public override SPHealthAnalysisRuleAutomaticExecutionParameters AutomaticExecutionParameters
        {
            get
            {
                return new SPHealthAnalysisRuleAutomaticExecutionParameters()
                {
                    Schedule = SPHealthCheckSchedule.Daily,
                    Scope = SPHealthCheckScope.Any,
                    ServiceType = typeof(SPWebService),
                    RepairAutomatically = false
                };
            }
        }

        public override SPHealthCheckStatus Check()
        {
            SPHealthCheckStatus outval = SPHealthCheckStatus.Failed;

            LoggingService.Write(LoggingService.Category_WebApp, TraceSeverity.Verbose, "Executing Health Check", CorrelationToken);

            // running as the system account
            Microsoft.SharePoint.SPSecurity.RunWithElevatedPrivileges(delegate { outval = doCheck(); });

            LoggingService.Write(
                LoggingService.Category_WebApp,
                TraceSeverity.Verbose,
                String.Format("Health Check completed with outcome {0}", outval.ToString()),
                CorrelationToken);

            return outval;
        } // void Check()

        public override string Summary { get { return "DELEGATION: Service Principal Names (SPNs) not configured for Kerberos Web Applications"; } }
        public override string Explanation
        {
            get
            {
                return String.Format(
                    @"{0}Some Kerberos clients exhibit a known issue forming SPNs for websites configured on non-default ports." +
                    @"{0}More info at: http://technet.microsoft.com/en-us/library/gg502606.aspx" +
                    @"{0}" +
                    @"{0}The following SPN errors were found:" +
                    @"{0}{1}" +
                    @"{0}",

                    Environment.NewLine,
                    String.Join(Environment.NewLine, Errors.Select(e => e.ErrorMessage).ToArray())
                    ).Trim();
            }
        }
        public override string Remedy
        {
            get
            {
                return String.Format(
                    @"{0}The following actions are required:" +
                    @"{0}{1}",

                    Environment.NewLine,
                    String.Join(Environment.NewLine, Errors.Select(e => e.Remedy).ToArray())
                    ).Trim();
            }
        }




        private SPHealthCheckStatus doCheck()
        {

            try
            {

                // use the local server only; the rule is configured to run on every server running SPWebService
                var Server = Microsoft.SharePoint.Administration.SPServer.Local;
                
                string ServerName = Server.Name;

                // loop through each WebApplication
                foreach (var WebApp in Server.WebApplications()
                                             .UsingKerberos())
                {
                    // fully qualified account name (DOMAIN\user)
                    var AppPoolServiceAccount = WebApp.ApplicationPool.Username;
                    // username only
                    var AppPoolServiceAccount_Username = AppPoolServiceAccount.Split('\\').Last();

                    // loop through each Zone using Kerberos Authentication
                    foreach (var IisSetting in WebApp.IisSettings.UsingKerberos().Select(i => i.Value))
                    {
                        // find ALL IIS HostHeader addresses
                        var Addresses = IisSetting.ServerBindings.SelectMany(b => b.SPN_Addresses()).Select(a => new { proto = "http", address = a })
                                 .Union(IisSetting.SecureBindings.SelectMany(b => b.SPN_Addresses()).Select(a => new { proto = "https", address = a }));

                        // check SecureBindings (https)
                        foreach (var address in Addresses)
                        {
                            // handy to keep this around
                            string Url = String.Format("{0}://{1}", address.proto, address.address);
                            string Spn = String.Format("{0}/{1}", address.proto.ToUpper(), address.address.ToLower());

                            // Where filter will remove FQDN's when address is only hostname
                            var MatchingSPNs = Helpers.AD_SPN.Find(address.proto, address.address, null).Where(s => s.Hostname.ToLower() == address.address.ToLower());
                            var MatchingSPNs_Count = MatchingSPNs.Count();

                            bool SpnConditionsHandled = false;

                            // if no SPNs
                            if (MatchingSPNs_Count == 0)
                            {
                                Errors.Add(new Error()
                                {
                                    WebAppName = WebApp.DisplayName,
                                    Url = Url,
                                    ErrorMessage = String.Format("SPN was not defined for [{0}]", Spn),
                                    Remedy = String.Format("Add SPN [{0}] using service account [{1}]", Spn, AppPoolServiceAccount)
                                });
                                SpnConditionsHandled = true;
                            }
                                
                            // if too many SPNs
                            if (MatchingSPNs_Count > 1)
                            {
                                var Accounts = String.Join(", ",  MatchingSPNs.Select(s => s.Account).ToArray());
                                var MatchingSpns_Contains_AppPoolAccount = MatchingSPNs.Select(s => s.Account.ToLower())
                                                                                       .Contains(AppPoolServiceAccount.Split('\\').Last().ToLower());
                                var Accounts_ToRemove = String.Join(", ",
                                                                    MatchingSPNs.Where(s => s.Account.ToLower() != AppPoolServiceAccount.Split('\\').Last().ToLower())
                                                                                .Select(s => s.Account)
                                                                                .ToArray()
                                                                    );

                                if (MatchingSpns_Contains_AppPoolAccount)
                                {
                                    Errors.Add(new Error()
                                    {
                                        WebAppName = WebApp.DisplayName,
                                        Url = Url,
                                        ErrorMessage = String.Format("Duplicate SPNs found [{0}] with accounts [{1}]", Spn, Accounts),
                                        Remedy = String.Format("Remove SPNs [{0}] using account [{1}]", Spn, Accounts_ToRemove)
                                    });
                                }
                                else
                                {
                                    Errors.Add(new Error()
                                    {
                                        WebAppName = WebApp.DisplayName,
                                        Url = Url,
                                        ErrorMessage = String.Format("Duplicate SPNs found [{0}] with accounts [{1}]", Spn, Accounts),
                                        Remedy = String.Format("Remove SPNs [{0}] using account [{1}]; add SPN [{0}] using account [{2}]", Spn, Accounts_ToRemove, AppPoolServiceAccount)
                                    });
                                }

                                SpnConditionsHandled = true;
                            }
                                
                            // if URL has one SPN, but specifies the wrong service account
                            if (MatchingSPNs_Count == 1 && MatchingSPNs.Single().Account.ToLower() != AppPoolServiceAccount_Username.ToLower())
                            {
                                Errors.Add(new Error()
                                {
                                    WebAppName = WebApp.DisplayName,
                                    Url = Url,
                                    ErrorMessage = String.Format("SPN [{0}] is defined with the wrong service account: [{1}] instead of [{2}]", Spn, MatchingSPNs.Single().Account, AppPoolServiceAccount),
                                    Remedy = String.Format("Remove SPN [{0}] using account [{1}]; add SPN [{0}] using account [{2}]", Spn, MatchingSPNs.Single().Account, AppPoolServiceAccount)
                                });
                                SpnConditionsHandled = true;
                            }

                            // log the good matches... useful for unit testing
                            if (MatchingSPNs_Count == 1 && MatchingSPNs.Single().Account.ToLower() == AppPoolServiceAccount_Username.ToLower())
                            {
                                CheckLogs.Add(new TestLog()
                                {
                                    WebAppName = WebApp.DisplayName,
                                    Url = Url,
                                    ServiceAccount = AppPoolServiceAccount_Username,

                                    SPN_Proto = MatchingSPNs.Single().ClassName,
                                    SPN_Address = MatchingSPNs.Single().Hostname,
                                    SPN_Account = MatchingSPNs.Single().Account
                                });
                                SpnConditionsHandled = true;
                            }

                            if (!SpnConditionsHandled)
                            {
                                // Log any remaining (invalid / unhandled) conditions...
                                LoggingService.Write(
                                    LoggingService.Category_WebApp,
                                    TraceSeverity.Unexpected,
                                    String.Format(
                                        @"Rule [{0}] experienced an unhandled condition {{ Url [{1}], SPNs [{2}], Account [{3}] }}",

                                        this.GetType().FullName,
                                        Url,
                                        String.Join(", ", MatchingSPNs.Select(s => String.Format("{{ {0}/{1} {2} }}", s.ClassName, s.Hostname, s.Account)).ToArray()),
                                        AppPoolServiceAccount
                                        ),
                                    CorrelationToken);
                            } // unhandled SPN condition

                        } // foreach HostHeader address
                    } // foreach Zone
                } // foreach WebApp

            } // try
            catch (Exception ex)
            {
                LoggingService.Write(
                    LoggingService.Category_WebApp,
                    TraceSeverity.High,
                    String.Format("Health Check encountered the following exception: {0}", ex.Message),
                    CorrelationToken);
            }


            // set return value
            return Errors.Count == 0
                   ? SPHealthCheckStatus.Passed
                   : SPHealthCheckStatus.Failed;

        } // doCheck()
    } // class


    public static class ExtensionMethods
    {
        private static string DomainName = System.DirectoryServices.ActiveDirectory.Domain.GetComputerDomain().Name;

        public static IEnumerable<string> SPN_Addresses(this SPServerBinding binding)
        {
            string PortIdentifier = binding.Port == 80 ? "" : String.Format(":{0}", binding.Port);

            // if host header is defined
            if (!String.IsNullOrEmpty(binding.HostHeader))
            {
                // check the hostheader address
                return new string[]
                {
                    // hostheader without port
                    String.Format("{0}", binding.HostHeader),
                    // hostheader with port
                    String.Format("{0}{1}", binding.HostHeader, PortIdentifier)
                }.Distinct();
            }
            else // binding.HostHeader is null or empty
            {
                // no host header means we check both hostname and FQDN
                return new string[]
                {
                    // hostname without port
                    String.Format("{0}", Environment.MachineName),
                    // hostname with port
                    String.Format("{0}{1}", Environment.MachineName, PortIdentifier),
                    // fqdn without port
                    String.Format("{0}.{1}", Environment.MachineName, DomainName),
                    // fqdn with port
                    String.Format("{0}.{1}{2}", Environment.MachineName, DomainName, PortIdentifier)
                }.Distinct(); // remove duplicates (if using default port)
            } // else (binding.hostheader is null/empty)

        } // SPN_Addresses(...)
        
        public static IEnumerable<string> SPN_Addresses(this SPSecureBinding binding)
        {
            string PortIdentifier = binding.Port == 443 ? "" : String.Format(":{0}", binding.Port);

            // if host header is defined
            if (!String.IsNullOrEmpty(binding.HostHeader))
            {
                // check the hostheader address
                return new string[]
                {
                    // hostheader without port
                    String.Format("{0}", binding.HostHeader),
                    // hostheader with port
                    String.Format("{0}{1}", binding.HostHeader, PortIdentifier)
                };
            }
            else // binding.HostHeader is null or empty
            {
                // no host header means we check both hostname and FQDN
                return new string[]
                {
                    // hostname without port
                    String.Format("{0}", Environment.MachineName),
                    // hostname with port
                    String.Format("{0}{1}", Environment.MachineName, PortIdentifier),
                    // fqdn without port
                    String.Format("{0}.{1}", Environment.MachineName, DomainName),
                    // fqdn with port
                    String.Format("{0}.{1}{2}", Environment.MachineName, DomainName, PortIdentifier)
                };
            } // else (binding.hostheader is null/empty)

        } // SPN_Addresses(...)

    } // class

} // namespace
